03244a86525446f36fd1fc4dfef07e66f6887b9f
[project/luci.git] /
1 'use strict';
2 'require view';
3 'require form';
4 'require uci';
5 'require rpc';
6 'require ui';
7 'require poll';
8 'require request';
9 'require dom';
10 'require fs';
11
12 const callPackagelist = rpc.declare({
13 object: 'rpc-sys',
14 method: 'packagelist',
15 });
16
17 const callSystemBoard = rpc.declare({
18 object: 'system',
19 method: 'board',
20 });
21
22 const callUpgradeStart = rpc.declare({
23 object: 'rpc-sys',
24 method: 'upgrade_start',
25 params: ['keep'],
26 });
27
28 /**
29 * Returns the branch of a given version. This helps to offer upgrades
30 * for point releases (aka within the branch).
31 *
32 * Logic:
33 * SNAPSHOT -> SNAPSHOT
34 * 21.02-SNAPSHOT -> 21.02
35 * 21.02.0-rc1 -> 21.02
36 * 19.07.8 -> 19.07
37 *
38 * @param {string} version
39 * Input version from which to determine the branch
40 * @returns {string}
41 * The determined branch
42 */
43 function get_branch(version) {
44 return version.replace('-SNAPSHOT', '').split('.').slice(0, 2).join('.');
45 }
46
47 /**
48 * The OpenWrt revision string contains both a hash as well as the number
49 * commits since the OpenWrt/LEDE reboot. It helps to determine if a
50 * snapshot is newer than another.
51 *
52 * @param {string} revision
53 * Revision string of a OpenWrt device
54 * @returns {integer}
55 * The number of commits since OpenWrt/LEDE reboot
56 */
57 function get_revision_count(revision) {
58 return parseInt(revision.substring(1).split('-')[0]);
59 }
60
61 return view.extend({
62 steps: {
63 init: [ 0, _('Received build request')],
64 container_setup: [ 10, _('Setting up ImageBuilder')],
65 validate_revision: [ 20, _('Validating revision')],
66 validate_manifest: [ 30, _('Validating package selection')],
67 calculate_packages_hash: [ 40, _('Calculating package hash')],
68 building_image: [ 50, _('Generating firmware image')],
69 signing_images: [ 95, _('Signing images')],
70 done: [100, _('Completed generating firmware image')],
71 failed: [100, _('Failed to generate firmware image')],
72
73 /* Obsolete status values, retained for backward compatibility. */
74 download_imagebuilder: [ 20, _('Downloading ImageBuilder archive')],
75 unpack_imagebuilder: [ 40, _('Setting Up ImageBuilder')],
76 },
77
78 request_hash: '',
79 sha256_unsigned: '',
80
81 applyPackageChanges: async function(packages, data, firmware) {
82 const overview_url = `${data.url}/api/v1/overview`;
83 const revision_url = `${data.url}/api/v1/revision/${firmware.version}/${firmware.target}`;
84
85 let changes, target_revision;
86
87 await Promise.all([
88 request.get(overview_url).then(
89 (response) => {
90 let json = response.json();
91 changes = json.branches[get_branch(firmware.version)].package_changes;
92 },
93 (failed) => {
94 ui.addNotification(null, E('p', _(`Get overview failed ${failed}`)));
95 }
96 ),
97 request.get(revision_url).then(
98 (response) => {
99 target_revision = get_revision_count(response.json().revision);
100 },
101 (failed) => {
102 ui.addNotification(null, E('p', _(`Get revision failed ${failed}`)));
103 }
104 ),
105 ]);
106
107 for (const change of changes) {
108 let idx = packages.indexOf(change.source);
109 if (idx >= 0 && change.revision <= target_revision) {
110 if (change.target)
111 packages[idx] = change.target;
112 else
113 packages.splice(idx, 1);
114 }
115 }
116 return packages;
117 },
118
119 selectImage: function (images, data, firmware) {
120 var filesystemFilter = function(e) {
121 return (e.filesystem == firmware.filesystem);
122 }
123 var typeFilter = function(e) {
124 let efi_targets = ['armsr', 'loongarch', 'x86'];
125 let efi_capable = efi_targets.some((tgt) => firmware.target.startsWith(tgt));
126 if (efi_capable) {
127 if (data.efi) {
128 return (e.type == 'combined-efi');
129 } else {
130 return (e.type == 'combined');
131 }
132 } else {
133 return (e.type == 'sysupgrade' || e.type == 'combined');
134 }
135 }
136 return images.filter(filesystemFilter).filter(typeFilter)[0];
137 },
138
139 handle200: function (response, content, data, firmware) {
140 response = response.json();
141 let image = this.selectImage(response.images, data, firmware);
142
143 if (image.name != undefined) {
144 this.sha256_unsigned = image.sha256_unsigned;
145 let sysupgrade_url = `${data.url}/store/${response.bin_dir}/${image.name}`;
146
147 let keep = E('input', { type: 'checkbox' });
148 keep.checked = true;
149
150 let fields = [
151 _('Version'),
152 `${response.version_number} ${response.version_code}`,
153 _('SHA256'),
154 image.sha256,
155 ];
156
157 if (data.advanced_mode == 1) {
158 fields.push(
159 _('Profile'),
160 response.id,
161 _('Target'),
162 response.target,
163 _('Build Date'),
164 response.build_at,
165 _('Filename'),
166 image.name,
167 _('Filesystem'),
168 image.filesystem
169 );
170 }
171
172 fields.push(
173 '',
174 E('a', { href: sysupgrade_url }, _('Download firmware image'))
175 );
176 if (data.rebuilder) {
177 fields.push(_('Rebuilds'), E('div', { id: 'rebuilder_status' }));
178 }
179
180 let table = E('div', { class: 'table' });
181
182 for (let i = 0; i < fields.length; i += 2) {
183 table.appendChild(
184 E('tr', { class: 'tr' }, [
185 E('td', { class: 'td left', width: '33%' }, [fields[i]]),
186 E('td', { class: 'td left' }, [fields[i + 1]]),
187 ])
188 );
189 }
190
191 let modal_body = [
192 table,
193 E(
194 'p',
195 { class: 'mt-2' },
196 E('label', { class: 'btn' }, [
197 keep,
198 ' ',
199 _('Keep settings and retain the current configuration'),
200 ])
201 ),
202 E('div', { class: 'right' }, [
203 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
204 ' ',
205 E(
206 'button',
207 {
208 class: 'btn cbi-button cbi-button-positive important',
209 click: ui.createHandlerFn(this, function () {
210 this.handleInstall(sysupgrade_url, keep.checked, image.sha256);
211 }),
212 },
213 _('Install firmware image')
214 ),
215 ]),
216 ];
217
218 ui.showModal(_('Successfully created firmware image'), modal_body);
219 if (data.rebuilder) {
220 this.handleRebuilder(content, data, firmware);
221 }
222 }
223 },
224
225 handle202: function (response) {
226 response = response.json();
227 this.request_hash = response.request_hash;
228
229 if ('queue_position' in response) {
230 ui.showModal(_('Queued...'), [
231 E(
232 'p',
233 { class: 'spinning' },
234 _('Request in build queue position %s').format(
235 response.queue_position
236 )
237 ),
238 ]);
239 } else {
240 ui.showModal(_('Building Firmware...'), [
241 E(
242 'p',
243 { class: 'spinning' },
244 _('Progress: %s%% %s').format(
245 this.steps[response.imagebuilder_status][0],
246 this.steps[response.imagebuilder_status][1]
247 )
248 ),
249 ]);
250 }
251 },
252
253 handleError: function (response, data, firmware) {
254 response = response.json();
255 const request_data = {
256 ...data,
257 request_hash: this.request_hash,
258 sha256_unsigned: this.sha256_unsigned,
259 ...firmware
260 };
261 let body = [
262 E('p', {}, _('Server response: %s').format(response.detail)),
263 E(
264 'a',
265 { href: 'https://github.com/openwrt/asu/issues' },
266 _('Please report the error message and request')
267 ),
268 E('p', {}, _('Request Data:')),
269 E('pre', {}, JSON.stringify({ ...request_data }, null, 4)),
270 ];
271
272 if (response.stdout) {
273 body.push(E('b', {}, 'STDOUT:'));
274 body.push(E('pre', {}, response.stdout));
275 }
276
277 if (response.stderr) {
278 body.push(E('b', {}, 'STDERR:'));
279 body.push(E('pre', {}, response.stderr));
280 }
281
282 body = body.concat([
283 E('div', { class: 'right' }, [
284 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
285 ]),
286 ]);
287
288 ui.showModal(_('Error building the firmware image'), body);
289 },
290
291 handleRequest: function (server, main, content, data, firmware) {
292 let request_url = `${server}/api/v1/build`;
293 let method = 'POST';
294 let local_content = content;
295
296 /**
297 * If `request_hash` is available use a GET request instead of
298 * sending the entire object.
299 */
300 if (this.request_hash && main == true) {
301 request_url += `/${this.request_hash}`;
302 local_content = {};
303 method = 'GET';
304 }
305
306 request
307 .request(request_url, { method: method, content: local_content })
308 .then((response) => {
309 switch (response.status) {
310 case 202:
311 if (main) {
312 this.handle202(response);
313 } else {
314 response = response.json();
315
316 let view = document.getElementById(server);
317 view.innerText = `⏳ (${
318 this.steps[response.imagebuilder_status][0]
319 }%) ${server}`;
320 }
321 break;
322 case 200:
323 if (main == true) {
324 poll.remove(this.pollFn);
325 this.handle200(response, content, data, firmware);
326 } else {
327 poll.remove(this.rebuilder_polls[server]);
328 response = response.json();
329 let view = document.getElementById(server);
330 let image = this.selectImage(response.images, data, firmware);
331 if (image.sha256_unsigned == this.sha256_unsigned) {
332 view.innerText = '✅ %s'.format(server);
333 } else {
334 view.innerHTML = `⚠️ ${server} (<a href="${server}/store/${
335 response.bin_dir
336 }/${image.name}">${_('Download')}</a>)`;
337 }
338 }
339 break;
340 case 400: // bad request
341 case 422: // bad package
342 case 500: // build failed
343 if (main == true) {
344 poll.remove(this.pollFn);
345 this.handleError(response, data, firmware);
346 break;
347 } else {
348 poll.remove(this.rebuilder_polls[server]);
349 document.getElementById(server).innerText = '🚫 %s'.format(
350 server
351 );
352 }
353 }
354 });
355 },
356
357 handleRebuilder: function (content, data, firmware) {
358 this.rebuilder_polls = {};
359 for (let rebuilder of data.rebuilder) {
360 this.rebuilder_polls[rebuilder] = L.bind(
361 this.handleRequest,
362 this,
363 rebuilder,
364 false,
365 content,
366 data,
367 firmware
368 );
369 poll.add(this.rebuilder_polls[rebuilder], 5);
370 document.getElementById(
371 'rebuilder_status'
372 ).innerHTML += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
373 }
374 poll.start();
375 },
376
377 handleInstall: function (url, keep, sha256) {
378 ui.showModal(_('Downloading...'), [
379 E(
380 'p',
381 { class: 'spinning' },
382 _('Downloading firmware from server to browser')
383 ),
384 ]);
385
386 request
387 .get(url, {
388 headers: {
389 'Content-Type': 'application/x-www-form-urlencoded',
390 },
391 responseType: 'blob',
392 })
393 .then((response) => {
394 let form_data = new FormData();
395 form_data.append('sessionid', rpc.getSessionID());
396 form_data.append('filename', '/tmp/firmware.bin');
397 form_data.append('filemode', 600);
398 form_data.append('filedata', response.blob());
399
400 ui.showModal(_('Uploading...'), [
401 E(
402 'p',
403 { class: 'spinning' },
404 _('Uploading firmware from browser to device')
405 ),
406 ]);
407
408 request
409 .get(`${L.env.cgi_base}/cgi-upload`, {
410 method: 'PUT',
411 content: form_data,
412 })
413 .then((response) => response.json())
414 .then((response) => {
415 if (response.sha256sum != sha256) {
416 ui.showModal(_('Wrong checksum'), [
417 E(
418 'p',
419 _('Error during download of firmware. Please try again')
420 ),
421 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
422 ]);
423 } else {
424 ui.showModal(_('Installing...'), [
425 E(
426 'p',
427 { class: 'spinning' },
428 _('Installing the sysupgrade. Do not unpower device!')
429 ),
430 ]);
431
432 L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
433 if (keep) {
434 ui.awaitReconnect(window.location.host);
435 } else {
436 ui.awaitReconnect('192.168.1.1', 'openwrt.lan');
437 }
438 });
439 }
440 });
441 });
442 },
443
444 handleCheck: function (data, firmware) {
445 this.request_hash = '';
446 let { url, revision, advanced_mode, branch } = data;
447 let { version, target, profile, packages } = firmware;
448 let candidates = [];
449
450 const endpoint = version.endsWith('SNAPSHOT') ? `revision/${version}/${target}` : 'overview';
451 const request_url = `${url}/api/v1/${endpoint}`;
452
453 ui.showModal(_('Searching...'), [
454 E(
455 'p',
456 { class: 'spinning' },
457 _('Searching for an available sysupgrade of %s - %s').format(
458 version,
459 revision
460 )
461 ),
462 ]);
463
464 L.resolveDefault(request.get(request_url)).then((response) => {
465 if (!response.ok) {
466 ui.showModal(_('Error connecting to upgrade server'), [
467 E(
468 'p',
469 {},
470 _('Could not reach API at "%s". Please try again later.').format(
471 response.url
472 )
473 ),
474 E('pre', {}, response.responseText),
475 E('div', { class: 'right' }, [
476 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
477 ]),
478 ]);
479 return;
480 }
481 if (version.endsWith('SNAPSHOT')) {
482 const remote_revision = response.json().revision;
483 if (
484 get_revision_count(revision) < get_revision_count(remote_revision)
485 ) {
486 candidates.push([version, remote_revision]);
487 }
488 } else {
489 const latest = response.json().latest;
490
491 // ensure order: newest to oldest release
492 latest.sort().reverse();
493
494 for (let remote_version of latest) {
495 let remote_branch = get_branch(remote_version);
496
497 // already latest version installed
498 if (version == remote_version) {
499 break;
500 }
501
502 // skip branch upgrades outside the advanced mode
503 if (branch != remote_branch && advanced_mode == 0) {
504 continue;
505 }
506
507 candidates.unshift([remote_version, null]);
508
509 // don't offer branches older than the current
510 if (branch == remote_branch) {
511 break;
512 }
513 }
514 }
515
516 // allow to re-install running firmware in advanced mode
517 if (advanced_mode == 1) {
518 candidates.unshift([version, revision]);
519 }
520
521 if (candidates.length) {
522 let s, o;
523
524 let mapdata = {
525 request: {
526 profile,
527 version: candidates[0][0],
528 packages: Object.keys(packages).sort(),
529 },
530 };
531
532 let map = new form.JSONMap(mapdata, '');
533
534 s = map.section(
535 form.NamedSection,
536 'request',
537 '',
538 '',
539 'Use defaults for the safest update'
540 );
541 o = s.option(form.ListValue, 'version', 'Select firmware version');
542 for (let candidate of candidates) {
543 if (candidate[0] == version && candidate[1] == revision) {
544 o.value(
545 candidate[0],
546 _('[installed] %s').format(
547 candidate[1]
548 ? `${candidate[0]} - ${candidate[1]}`
549 : candidate[0]
550 )
551 );
552 } else {
553 o.value(
554 candidate[0],
555 candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
556 );
557 }
558 }
559
560 if (advanced_mode == 1) {
561 o = s.option(form.Value, 'profile', _('Board Name / Profile'));
562 o = s.option(form.DynamicList, 'packages', _('Packages'));
563 }
564
565 L.resolveDefault(map.render()).then((form_rendered) => {
566 ui.showModal(_('New firmware upgrade available'), [
567 E(
568 'p',
569 _('Currently running: %s - %s').format(
570 version,
571 revision
572 )
573 ),
574 form_rendered,
575 E('div', { class: 'right' }, [
576 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
577 ' ',
578 E(
579 'button',
580 {
581 class: 'btn cbi-button cbi-button-positive important',
582 click: ui.createHandlerFn(this, function () {
583 map.save().then(() => {
584 this.applyPackageChanges(mapdata.request.packages, data, firmware).then((packages) => {
585 const content = {
586 ...firmware,
587 packages: packages,
588 version: mapdata.request.version,
589 profile: mapdata.request.profile
590 };
591 this.pollFn = L.bind(function () {
592 this.handleRequest(url, true, content, data, firmware);
593 }, this);
594 poll.add(this.pollFn, 5);
595 poll.start();
596 });
597 });
598 }),
599 },
600 _('Request firmware image')
601 ),
602 ]),
603 ]);
604 });
605 } else {
606 ui.showModal(_('No upgrade available'), [
607 E(
608 'p',
609 _('The device runs the latest firmware version %s - %s').format(
610 version,
611 revision
612 )
613 ),
614 E('div', { class: 'right' }, [
615 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
616 ]),
617 ]);
618 }
619 });
620 },
621
622 load: async function () {
623 const promises = await Promise.all([
624 L.resolveDefault(callPackagelist(), {}),
625 L.resolveDefault(callSystemBoard(), {}),
626 L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
627 uci.load('attendedsysupgrade'),
628 ]);
629 const data = {
630 url: uci.get_first('attendedsysupgrade', 'server', 'url'),
631 branch: get_branch(promises[1].release.version),
632 revision: promises[1].release.revision,
633 efi: promises[2],
634 advanced_mode: uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0,
635 rebuilder: uci.get_first('attendedsysupgrade', 'server', 'rebuilder')
636 };
637 const firmware = {
638 client: 'luci/' + promises[0].packages['luci-app-attendedsysupgrade'],
639 packages: promises[0].packages,
640 profile: promises[1].board_name,
641 target: promises[1].release.target,
642 version: promises[1].release.version,
643 diff_packages: true,
644 filesystem: promises[1].rootfs_type
645 };
646 return [data, firmware];
647 },
648
649 render: function (response) {
650 const data = response[0];
651 const firmware = response[1];
652
653 return E('p', [
654 E('h2', _('Attended Sysupgrade')),
655 E(
656 'p',
657 _(
658 'The attended sysupgrade service allows to upgrade vanilla and custom firmware images easily.'
659 )
660 ),
661 E(
662 'p',
663 _(
664 'This is done by building a new firmware on demand via an online service.'
665 )
666 ),
667 E(
668 'p',
669 _('Currently running: %s - %s').format(
670 firmware.version,
671 data.revision
672 )
673 ),
674 E(
675 'button',
676 {
677 class: 'btn cbi-button cbi-button-positive important',
678 click: ui.createHandlerFn(this, this.handleCheck, data, firmware),
679 },
680 _('Search for firmware upgrade')
681 ),
682 ]);
683 },
684 handleSaveApply: null,
685 handleSave: null,
686 handleReset: null,
687 });